透過不同的發布策略,減少線上問題所帶來的影響之後。
我們就可以相對安心的處理專案了。
不過,有的問題是在人數少的時候還不會遇到狀況,但是在專案運行一段時間,資料量變多或者人數變多時,才會開始發生問題
今天我們來聊這一塊的狀況
N+1 問題是在使用 ORM 的時候,由於關聯非常的直觀好用,有時候我們會忽略背後的實作,是有可能導致問題的。
我們來看看一個範例,我們有兩個 Model User
和 Book
,之間是一對多關係
class User extends Model
{
public function books(): HasMany
{
return $this->hasMany(Book::class);
}
}
這時候,如果我們寫了一個迴圈,試著取得所有用戶,各自擁有的所有書本
Route::get('/books', function () {
$users = User::all();
$books = collect();
foreach ($users as $user) {
$userData = collect();
foreach ($user->books as $book) {
$userData->push($book->title);
}
$books->push($user);
}
return $books;
})->name('books');
就可以看到所有的書籍內容。
這段程式在剛開始運作,是不會有任何問題的,但是,隨著資料越來越多,我們就會發現這段程式明顯變慢了很多。
要理解這是怎麼回事,我們要使用一些工具,看看這段程式背後執行的 SQL Query
我們可以用 DB::enableQueryLog()
和 DB::getQueryLog()
幫我們達成這件事情
Route::get('/books', function () {
$users = User::all();
$books = collect();
foreach ($users as $user) {
$userData = collect();
foreach ($user->books as $book) {
$userData->push($book->title);
}
$books->push($user);
}
return DB::getQueryLog();
})->name('books');
我們會看到
[
{
"query": "select * from \"users\"",
"bindings": [],
"time": 0.04
},
{
"query": "select * from \"books\" where \"books\".\"user_id\" = ? and \"books\".\"user_id\" is not null",
"bindings": [
1
],
"time": 0.2
},
{
"query": "select * from \"books\" where \"books\".\"user_id\" = ? and \"books\".\"user_id\" is not null",
"bindings": [
2
],
"time": 0.03
},
{
"query": "select * from \"books\" where \"books\".\"user_id\" = ? and \"books\".\"user_id\" is not null",
"bindings": [
3
],
"time": 0.02
},
{
"query": "select * from \"books\" where \"books\".\"user_id\" = ? and \"books\".\"user_id\" is not null",
"bindings": [
4
],
"time": 0.02
}
]
這邊發生了什麼事情?
由於我們先透過了 User::all()
取出了所有的 user,然後在迴圈內才試著存取每個 user 裡面的 book。
所以在迴圈內,我們等於遇到一個 user,才去資料庫取出這個 user 對應的書籍。
這個作法有個術語,叫做 Lazy loading。
也就是說,要是我們有 N 個 user,那我們會在迴圈階段執行 N 次資料庫存取!
搭配上迴圈之前的 query,等於這段程式要跑 N+1 次 query。
在我們 user 越來越多的狀況下,這樣當然會對效能有不好的影響了。
要避免 N+1 問題,最簡單的方式,就是在前面取出資料時先將對應的資料取出來。
我們可以用 with()
來做到這件事情
Route::get('/books', function () {
$users = User::with('books')->get();
$books = collect();
foreach ($users as $user) {
$userData = collect();
foreach ($user->books as $book) {
$userData->push($book->title);
}
$books->push($user);
}
return DB::getQueryLog();
})->name('books');
這時我們看看 Query 的紀錄,可以看到
[
{
"query": "select * from \"users\"",
"bindings": [],
"time": 0.08
},
{
"query": "select * from \"books\" where \"books\".\"user_id\" in (1, 2, 3, 4)",
"bindings": [],
"time": 0.17
}
]
這邊由於 Laravel 知道我們後面將會需要使用 books
裡面的資料,所以直接用一個 Query 將所有用戶對應所有的 books 全部取出來。
這樣一來,就不會在迴圈裡面每次都存取一次資料庫了。
這個作法又稱為 eager loading。
Model::preventLazyLoading()
和 automaticallyEagerLoadRelationships()
話雖如此,當我們關聯變得複雜時,有時候總是會忘記或者漏掉對應的關聯要加入。
在 Laravel 12,特別加入了一個函式,可以一勞永逸的避免掉忘記加入關聯這個問題。
我們可以在 AppServierProvider.php
裡面加入
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading();
}
這時如果我們試著將前面的 User::with('books')->get()
改回 User::all()
我們就會看到錯誤訊息
# Illuminate\Database\LazyLoadingViolationException - Internal Server Error
Attempted to lazy load [books] on model [App\Models\User] but lazy loading is disabled.
PHP 8.4.8
Laravel 12.28.1
127.0.0.1:8000
## Stack Trace
...
如果我們不希望手寫所有的 with
,我們可以使用 Model::automaticallyEagerLoadRelationships()
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::automaticallyEagerLoadRelationships();
}
這樣一來,程式在運作的時候會自動的幫我們抓出所有的關聯。
即使我們使用 User::all()
的方式,也不會出現 N+1 問題了
今天的部分就到這邊,我們明天見!